#!/usr/bin/env python

# Copyright (c) 2009 by Alex Leone <acleone ~at~ gmail.com>
#
# This file is part of epp.
#
# epp is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# epp is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with epp.  If not, see <http://www.gnu.org/licenses/>.

import sys
import os.path
import re
import tags
import cStringIO
from optparse import OptionParser

TAG = re.compile(r"\[([a-zA-Z_]\w*)[\s:\]$]")
TAG_ATTR = re.compile(r"\[!([a-zA-Z_]\w*)\]")
TAG_ATTR_START = re.compile(r"[!]?[a-zA-Z_]\w*")
TAG_ATTR_QUOTES = re.compile("\"|'|`")
TAG_ATTR_END_UNQUOTED = re.compile(r"[\s:\]]")
QUOTE_START_CHARS = ('"', "'", '`')
LINE_INDENT = re.compile(r"[ \t]*")
MULTILINE_INDENT = re.compile(r"\n([ \t]*)")
WHITESPACE = (' ', '\t', '\n')

def lstrip(s, i):
    """
    Moves i until s[i] is not in ('\n', ' ', '\t')
    Returns: i
    """
    maxI = len(s)
    while i < maxI and s[i] in WHITESPACE:
        i += 1
    return i

def num_chars_at_end(s, c):
    """
    Returns the number repeated character c at the end of s
    """
    slen = len(s)
    i = slen - 1
    while i >= 0 and s[i] == c:
        i -= 1
    return slen - i - 1

def parse_escaped_chars(s, quoteChar):
    """
    Replaces escaped characters with their equivalent
    quoteChar: the charcter that quotes this string
    """
    slen = len(s) - 1
    result = []
    lastSlashI = 0
    while True:
        nextSlashI = s.find('\\', lastSlashI)
        if nextSlashI == -1 or nextSlashI == slen:
            break
        result.append(s[lastSlashI:nextSlashI])
        c = s[nextSlashI + 1]
        if c == '\\':
            result.append('\\')
        elif c == quoteChar:
            result.append(quoteChar)
        elif c == 't':
            result.append('\t')
        elif c == 'n':
            result.append('\n')
        else:
            result.append("\\")
            result.append(c)
        lastSlashI = nextSlashI + 2
    result.append(s[lastSlashI:])
    return ''.join(result)

def is_quoted_string(s, i):
    """
    Assumes that i is lstrip()-ed to s.
    Returns: (i advanced to the beginning of the quoted string,
            quoteChar = None, isRaw = False)
    """
    maxI = len(s)
    isRaw = False
    if i < maxI:
        if s[i] == 'r':
            isRaw = True
            i += 1
        if i >= maxI or s[i] not in QUOTE_START_CHARS:
            if isRaw:
                i -= 1
            return (i, None, False)
        if i + 2 < maxI and s[i:i+3] in ('"""', "'''"):
            return (i + 3, s[i:i+3], isRaw)
        return (i + 1, s[i], isRaw)
    return (i, None, False)

def parse_quoted_str(s, i, quoteChar, isRaw):
    """
    Parses a quoted string and replaces escaped characters
    Assumes that i begins right after the quotes in s.
    Returns: (i, value)
             (None, None) if this string has an unclosed quote
    """
    nextQuoteI = i
    while True:
        nextQuoteI = s.find(quoteChar, nextQuoteI)
        if nextQuoteI == -1:
            return (None, None)
        value = s[i:nextQuoteI]
        if isRaw or num_chars_at_end(value, "\\") % 2 == 0:
            break
        nextQuoteI += 1
    if not isRaw:
        value = parse_escaped_chars(value, quoteChar)
    return (nextQuoteI + 1, value)

def get_attr_value(s, i, endUnquotedRE):
    """
    Assumes that i is lstrip()-ed to s.
    Param: endUnquotedRE is a regular expression that ends an unquoted value
    Returns: (i, value)
    """
    i, quoteChar, isRaw = is_quoted_string(s, i)
    if quoteChar == None:
        # unquoted string, go until we hit whitespace, :, or ]
        unquotedEnd = endUnquotedRE.search(s, i)
        if unquotedEnd:
            return (unquotedEnd.start(), s[i:unquotedEnd.start()])
        else:
            return (len(s), s[i:])
    else:
        # quoted string
        return parse_quoted_str(s, i, quoteChar, isRaw)

def parse_multiline(input_str, i, indentLen):
    """
    Parses an indented multi-line block.
    Returns: (i, value, closingIndex)
        where closingIndex is the index of where the closing tag could be,
        e.g. remaining = '\n\t[/define]', then closingIndex = i + 2
    """
    lines = []
    maxI = len(input_str)
    if i < maxI and input_str[i] == ']':
        i += 1
    closingIndex = i
    firstLine = True
    firstLineVal = None
    gap = 0
    while i < maxI:
        nextLineMatch = MULTILINE_INDENT.search(input_str, i)
        if nextLineMatch:
            thisLine = input_str[i:nextLineMatch.start()]
            if firstLine:
                firstLine = False
                thisLine = thisLine.lstrip()
                if len(thisLine) != 0:
                    firstLineVal = thisLine
            else:
                if len(thisLine.lstrip()) != 0:
                    if gap > 0:
                        lines.append('\n' * (gap - 1))
                        gap = 0
                    lines.append(thisLine)
                else:
                    # ignore blank lines
                    gap += 1
            i = nextLineMatch.start()
            nextLineIndent = nextLineMatch.group(1)
            if nextLineMatch.end() < maxI:
                # make sure the next line isn't blank
                if input_str[nextLineMatch.end()] != '\n' \
                    and len(nextLineIndent) <= indentLen:
                    # the next line will not be blank
                    # and is not indented far enough, break
                    closingIndex = nextLineMatch.end()
                    break
                # + 2 because we have to skip the newline and the indentation
                # or if the line was blank we don't want to skip characters
                i += min(indentLen + 2, len(nextLineMatch.group()))
            else:
                # only whitespace remains, trim it
                i = maxI
        else:
            # add this line and we're done
            if firstLine:
                l = input_str[i:].lstrip()
                if len(l) > 0:
                    firstLineVal = l
            else:
                lines.append(input_str[i:])
            i = maxI
    # find min indentation shared by all lines
    if len(lines) > 0:
        minI = None
        linesToChange = []
        for li, line in enumerate(lines):
            ls = len(line.lstrip())
            if ls != 0:
                linesToChange.append(li)
                ilen = len(line) - ls
                if minI == None or ilen < minI:
                    minI = ilen
        for li in linesToChange:
            lines[li] = lines[li][minI:]
    if firstLineVal != None:
        lines.insert(0, firstLineVal)
    return (i, '\n'.join(lines), closingIndex)

def get_indent_chars(remaining, startI):
    """
    Gets the Indentation characters for this line (that startI is on)
    Returns: indentChars
    """
    lastLineEOL = remaining.rfind('\n', 0, startI)
    if lastLineEOL != -1:
        lastLine = remaining[lastLineEOL + 1:startI]
    else:
        lastLine = remaining[:startI]
    indentMatch = LINE_INDENT.match(lastLine)
    return indentMatch.group()

def add_indent_chars(remaining, indentChars):
    """
    Adds indentChars after any newlines
    Returns: remaining with added indentChars
    """
    if len(indentChars) > 0:
        newlineAndIndent = '\n' + indentChars
        return newlineAndIndent.join(remaining.splitlines())
    return remaining

class Epp:
    _verbosity = 1

    def __init__(self, ptags = None, parent_parser = None):
        if ptags != None:
            self._tags = ptags
        else:
            self._tags = tags.builtin_tags.copy()
        self._parent_parser = parent_parser
        self._defined_attrs = []
        self._inname = ''

    def gettags(self):
        return self._tags

    def settags(self, ptags):
        self._tags = ptags

    def set_class_verbosity(self, verbosity):
        self.__class__.__dict__["_verbosity"] = verbosity

    def _print_message(self, msg, v_level = 1, line_num = None):
        if self.__class__.__dict__["_verbosity"] < v_level:
            return
        sys.stderr.write(
                "%s: %s\n"
                % (self._inname, msg))

    def parse_attrs(self, input_str, i, tagName, indentLen, hasRecursed = False):
        """
        i is assumed to be lstrip()-ed on input_str
        hasRecursed will be True if we are parsing a multi-line
        tag w/o attrs, eg [tag:] attr1 = "..."
        Returns: (i, attrs)
        """
        attrs = []
        attrName = None
        seenEquals = False
        maxI = len(input_str)
        while i < maxI:
            if input_str[i] == ']':
                i += 1
                if attrName != None:
                    attrs.append((attrName, None))
                break
            elif input_str[i] == ':':
                i, value, closingIndex = parse_multiline(input_str, i + 1, indentLen)
                closeTag = r"[ \t]*\[/(?:"
                if hasRecursed:
                    # no opening brackets if this is a multi-line attr spec
                    closeTag = r"[ \t]*/(?:"
                if attrName != None:
                    # this was an attr value multi-line, eg [tag attr1:] ...
                    closeTag += attrName + "|"
                    attrs.append((attrName, value))
                    attrName = None
                else:
                    # this was a multi-line attr, eg [tag:] ...
                    i2, attrs = self.parse_attrs(value.lstrip(), 0, tagName, 0, True)
                if i >= maxI:
                    break
                closeTag += tagName + ")"
                closeTagRE = re.compile(closeTag)
                closeTagMatch = closeTagRE.match(input_str, closingIndex)
                if closeTagMatch:
                    # explicit closing, remove it and keep parsing attrs
                    i = lstrip(input_str, closeTagMatch.end())
                elif hasRecursed:
                    # multi-line attr, so keep going
                    i = lstrip(input_str, i)
                else:
                    # implicit close of tag - we're done.
                    # remaining will start with '\n\t\t\t...'
                    break
            elif attrName != None and input_str[i] == '=':
                seenEquals = True
                i = lstrip(input_str, i + 1)
            elif seenEquals:
                seenEquals = False
                i, value = get_attr_value(input_str, i, TAG_ATTR_END_UNQUOTED)
                if i == None and value == None:
                    self._print_message(
                            "Error: [%s] %r Unclosed quote"
                            % (tagName, attrName), 0)
                    break
                attrs.append((attrName, value))
                attrName = None
                i = lstrip(input_str, i)
            else:
                if attrName != None:
                    attrs.append((attrName, None))
                    attrName = None
                attrNameMatch = TAG_ATTR_START.match(input_str, i)
                if not attrNameMatch:
                    self._print_message(
                            "Error: Invalid attribute syntax in tag %r"
                            % (tagName), 0)
                    sys.stderr.write("Near %r\n" % (input_str[i-20:i+20]))
                    break
                attrName = attrNameMatch.group()
                i = lstrip(input_str, attrNameMatch.end())
        else:
            if not hasRecursed:
                self._print_message(
                        "Error: hit EOF with unclosed tag %r"
                        % (tagName), 0)
        return (i, attrs)

    def parsetags(self, input_str):
        """
        Parses all the tags in remaining string.
        Returns: the output as a string
        """
        output = cStringIO.StringIO()
        i = 0
        maxI = len(input_str)
        while True:
            tagMatch = TAG.search(input_str, i)
            if not tagMatch:
                break
            tagName = tagMatch.group(1)
            thisLineIndent = get_indent_chars(input_str, tagMatch.start())
            indentLen = len(thisLineIndent)
            prevI = i
            newLineI = tagMatch.start() - indentLen - 1
            isFirstTagOnLine = (newLineI < 0) or (input_str[newLineI] == '\n')
            i = tagMatch.end() - 1
            if tagName in self._tags:
                i = lstrip(input_str, i)
                self._print_message("Found tag %r" % (tagName), 5)
                i, attrs = self.parse_attrs(input_str, i, tagName, indentLen)
                f = self._tags[tagName]
                replacement = f.replace(self, tagName, attrs)
                nextNewlineI = LINE_INDENT.match(input_str, i).end()
                if replacement == '' and isFirstTagOnLine and (
                    nextNewlineI >= maxI or input_str[nextNewlineI] == '\n'):
                    # if all this line had was a define or something that
                    # had a replacement of '', remove the line from the output
                    output.write(input_str[prevI:newLineI + 1])
                    i = nextNewlineI + 1
                else:
                    output.write(input_str[prevI:tagMatch.start()])
                    # multi-line replacements need to have the same tab level as
                    # the line that they're on
                    output.write(add_indent_chars(replacement, thisLineIndent))
            else:
                output.write(input_str[prevI:tagMatch.start()])
                output.write(tagMatch.group()[:-1])
                self._print_message(
                        "Warning: undefined tag %r"
                        % (tagName), 2)
        output.write(input_str[i:])
        output_str = output.getvalue()
        output.close()
        return output_str

    def hasDefinedAttr(self, attrName):
        """
        Returns: True if attrName is in self._defined_attrs
        """
        for definedAttrs in self._defined_attrs:
            if attrName in definedAttrs:
                return True
        return False

    def get_defined_attr(self, attrName):
        """
        Returns: the attr value.  Starts searching from the end of the list
        """
        for definedAttrs in reversed(self._defined_attrs):
            if attrName in definedAttrs:
                return definedAttrs[attrName]
        return None

    def parsefile(self, input_file):
        """
        Assumes input_file and output_file are already open, etc.
        This function does not close the files.
        Returns: a string with all the tags parsed.
        """
        remaining = '\n'.join(input_file.read().splitlines())
        return self.parsetags(remaining)

    def setfileroots(self, inroot, outroot):
        """
        Sets the input and output directories.
        Assumes that inroot and outroot end with os.sep
        """
        self._inroot = inroot
        self._outroot = outroot

    def getinroot(self):
        return self._inroot

    def getoutroot(self):
        return self._outroot

    def setrelpath(self, relpath):
        """
        Sets the relative pathname.
        """
        self._relpath = relpath

    def getrelpath(self):
        return self._relpath

    def setfilenames(self, inname, outname=''):
        """
        Sets the input and output file names. (eg filename.ext)
        """
        self._inname = inname
        self._outname = outname

    def getinname(self):
        return self._inname

    def getoutname(self):
        return self._outname

def main():
    """
    Returns: exit code
    """
    usage = "Usage: %prog [options] inputfile"
    optparser = OptionParser(usage)
    optparser.add_option("-o", "--output-file", dest="outfile",
                        help="set the output file to OUTFILE")
    (options, args) = optparser.parse_args()

    if len(args) == 0:
        inname = 'stdin'
        infile = sys.stdin
        closeinfile = False
    else:
        inname = args[0]
        infile = open(args[0], 'r')
        closeinfile = True

    outfile = options.outfile
    if outfile == None:
        outname = 'stdout'
        outfile = sys.stdout
        closeoutfile = False
    else:
        outname = outfile
        outfile = open(outfile, 'w')
        closeoutfile = True

    parser = Epp()
    parser.setfilenames(inname, outname)
    outputstr = parser.parsefile(infile)
    outfile.write(outputstr)

    if closeinfile:
        infile.close()
    if closeoutfile:
        outfile.close()

    return os.EX_OK

if __name__ == "__main__":
    sys.exit(main())